/**
 * Copyright © 2024 650 Industries.
 * Copyright © 2024 2023 lubieowoce
 *
 * This source code is licensed under the MIT license found in the
 * LICENSE file in the root directory of this source tree.
 *
 * https://github.com/lubieowoce/tangle/blob/5229666fb317d0da9363363fc46dc542ba51e4f7/packages/babel-rsc/src/babel-rsc-actions.ts#L1C1-L909C25
 */

import type { ConfigAPI, types as t, NodePath, PluginObj, PluginPass } from '@babel/core';
import { relative as getRelativePath } from 'node:path';
import url from 'node:url';

import { createAddNamedImportOnce, getPossibleProjectRoot, toPosixPath } from './common';

const debug = require('debug')('expo:babel:server-actions');

type FnPath =
  | NodePath<t.ArrowFunctionExpression>
  | NodePath<t.FunctionDeclaration>
  | NodePath<t.FunctionExpression>;

type ExtractedActionInfo = { localName?: string; exportedName: string };

const LAZY_WRAPPER_VALUE_KEY = 'value';

export function reactServerActionsPlugin(
  api: ConfigAPI & typeof import('@babel/core')
): PluginObj<PluginPass> {
  const { types: t } = api;

  // React doesn't like non-enumerable properties on serialized objects (see `isSimpleObject`),
  // so we have to use closure scope for the cache (instead of a non-enumerable `this._cache`)
  const _buildLazyWrapperHelper = api.template(`(thunk) => {
    let cache;
    return {
      get ${LAZY_WRAPPER_VALUE_KEY}() {
        return cache || (cache = thunk());
      }
    }
  }`);

  const buildLazyWrapperHelper = () => {
    return (_buildLazyWrapperHelper() as t.ExpressionStatement).expression;
  };

  const possibleProjectRoot = api.caller(getPossibleProjectRoot);
  let addReactImport: () => t.Identifier;
  let wrapBoundArgs: (expr: t.Expression) => t.Expression;
  let getActionModuleId: () => string;

  const extractInlineActionToTopLevel = (
    path: FnPath,
    _state: PluginPass,
    {
      body,
      freeVariables,
    }: {
      body: t.BlockStatement;
      freeVariables: string[];
    }
  ) => {
    const actionModuleId = getActionModuleId();
    const moduleScope = path.scope.getProgramParent();
    const extractedIdentifier = moduleScope.generateUidIdentifier('$$INLINE_ACTION');

    let extractedFunctionParams = [...path.node.params];
    let extractedFunctionBody = body.body;
    if (freeVariables.length > 0) {
      // only add a closure object if we're not closing over anything.
      // const [x, y, z] = await _decryptActionBoundArgs(await $$CLOSURE.value);

      const closureParam = path.scope.generateUidIdentifier('$$CLOSURE');
      const freeVarsPat = t.arrayPattern(freeVariables.map((variable) => t.identifier(variable)));

      const closureExpr = t.memberExpression(closureParam, t.identifier(LAZY_WRAPPER_VALUE_KEY));

      extractedFunctionParams = [closureParam, ...path.node.params];
      extractedFunctionBody = [
        t.variableDeclaration('var', [
          t.variableDeclarator(t.assignmentPattern(freeVarsPat, closureExpr)),
        ]),
        ...extractedFunctionBody,
      ];
    }

    const wrapInRegister = (expr: t.Expression, exportedName: string) => {
      const expoRegisterServerReferenceId = addReactImport();
      return t.callExpression(expoRegisterServerReferenceId, [
        expr,
        t.stringLiteral(actionModuleId),
        t.stringLiteral(exportedName),
      ]);
    };

    const isArrowFn = path.isArrowFunctionExpression();

    const extractedFunctionExpr = wrapInRegister(
      isArrowFn
        ? t.arrowFunctionExpression(
            extractedFunctionParams,
            t.blockStatement(extractedFunctionBody),
            true
          )
        : t.functionExpression(
            path.node.id,
            extractedFunctionParams,
            t.blockStatement(extractedFunctionBody),
            false,
            true
          ),
      extractedIdentifier.name
    );

    // Create a top-level declaration for the extracted function.
    const bindingKind = 'var';
    const functionDeclaration = t.exportNamedDeclaration(
      t.variableDeclaration(bindingKind, [
        t.variableDeclarator(extractedIdentifier, extractedFunctionExpr),
      ])
    );

    // Insert the declaration as close to the original declaration as possible.

    const isPathFunctionInTopLevel = path.find((p) => p.isProgram()) === path;

    const decl = isPathFunctionInTopLevel ? path : findImmediatelyEnclosingDeclaration(path);
    let inserted: NodePath<t.ExportNamedDeclaration>;

    const canInsertExportNextToPath = (decl: NodePath) => {
      if (!decl) {
        return false;
      }
      if (decl.parentPath?.isProgram()) {
        return true;
      }

      return false;
    };

    const findNearestPathThatSupportsInsertBefore = (decl: NodePath) => {
      let current = decl;

      // Check if current scope is suitable for `export` insertion
      while (current && !current.isProgram()) {
        if (canInsertExportNextToPath(current)) {
          return current;
        }
        const parentPath = current.parentPath;
        if (!parentPath) {
          return null;
        }
        current = parentPath;
      }

      if (current.isFunction()) {
        // Don't insert exports inside functions
        return null;
      }

      return current;
    };

    const topLevelDecl = decl ? findNearestPathThatSupportsInsertBefore(decl) : null;

    if (topLevelDecl) {
      // If it's a variable declaration, insert before its parent statement to avoid syntax errors
      const targetPath = topLevelDecl.isVariableDeclarator()
        ? topLevelDecl.parentPath
        : topLevelDecl;
      [inserted] = targetPath.insertBefore(functionDeclaration);
      moduleScope.registerBinding(bindingKind, inserted);
      inserted.addComment(
        'leading',
        ' hoisted action: ' + (getFnPathName(path) ?? '<anonymous>'),
        true
      );
    } else {
      // Fallback to inserting after the last import if no enclosing declaration is found
      const programBody = moduleScope.path.get('body');
      const lastImportPath = (Array.isArray(programBody) ? programBody : [programBody]).findLast(
        (statement) => {
          return statement.isImportDeclaration();
        }
      );

      [inserted] = lastImportPath!.insertAfter(functionDeclaration);
      moduleScope.registerBinding(bindingKind, inserted);
      inserted.addComment(
        'leading',
        ' hoisted action: ' + (getFnPathName(path) ?? '<anonymous>'),
        true
      );
    }

    return {
      inserted,
      extractedIdentifier,
      getReplacement: () =>
        getInlineActionReplacement({
          id: extractedIdentifier,
          freeVariables,
        }),
    };
  };

  const getInlineActionReplacement = ({
    id,
    freeVariables,
  }: {
    id: t.Identifier;
    freeVariables: string[];
  }) => {
    if (freeVariables.length === 0) {
      return id;
    }

    const capturedVarsExpr = t.arrayExpression(
      freeVariables.map((variable) => t.identifier(variable))
    );
    const boundArgs = wrapBoundArgs(capturedVarsExpr);

    // _ACTION.bind(null, { get value() { return _encryptActionBoundArgs([x, y, z]) } })

    return t.callExpression(t.memberExpression(id, t.identifier('bind')), [
      t.nullLiteral(),
      boundArgs,
    ]);
  };

  function hasUseServerDirective(path: FnPath) {
    const { body } = path.node;
    return t.isBlockStatement(body) && body.directives.some((d) => d.value.value === 'use server');
  }

  return {
    name: 'expo-server-actions',
    pre(file) {
      const projectRoot = possibleProjectRoot || file.opts.root || '';

      if (!file.code.includes('use server')) {
        file.path.skip();
        return;
      }

      assertExpoMetadata(file.metadata);
      file.metadata.extractedActions = [];
      file.metadata.isModuleMarkedWithUseServerDirective = false;

      const addNamedImportOnce = createAddNamedImportOnce(t);

      addReactImport = () => {
        return addNamedImportOnce(
          file.path,
          'registerServerReference',
          'react-server-dom-webpack/server'
        );
      };

      getActionModuleId = once(() => {
        // Create relative file path hash.
        return './' + toPosixPath(getRelativePath(projectRoot, file.opts.filename!));
      });

      const defineBoundArgsWrapperHelper = once(() => {
        const id = this.file.path.scope.generateUidIdentifier('wrapBoundArgs');
        this.file.path.scope.push({
          id,
          kind: 'var',
          init: buildLazyWrapperHelper(),
        });
        return id;
      });

      wrapBoundArgs = (expr) => {
        const wrapperFn = t.cloneNode(defineBoundArgsWrapperHelper());
        return t.callExpression(wrapperFn, [t.arrowFunctionExpression([], expr)]);
      };
    },

    visitor: {
      Program(path, state) {
        if (path.node.directives.some((d) => d.value.value === 'use server')) {
          assertExpoMetadata(state.file.metadata);

          state.file.metadata.isModuleMarkedWithUseServerDirective = true;

          // remove the directive so that downstream consumers don't transform the module again.
          path.node.directives = path.node.directives.filter((d) => d.value.value !== 'use server');
        }
      },

      // `() => {}`
      ArrowFunctionExpression(path, state) {
        const { body } = path.node;
        if (!t.isBlockStatement(body) || !hasUseServerDirective(path)) {
          return;
        }
        assertIsAsyncFn(path);

        const freeVariables = getFreeVariables(path);
        const tlb = getTopLevelBinding(path);

        const { extractedIdentifier, getReplacement } = extractInlineActionToTopLevel(path, state, {
          freeVariables,
          body,
        });

        path.replaceWith(getReplacement());
        assertExpoMetadata(state.file.metadata);
        state.file.metadata.extractedActions.push({
          localName: tlb?.identifier.name,
          exportedName: extractedIdentifier.name,
        });
      },

      // `function foo() { ... }`
      FunctionDeclaration(path, state) {
        if (!hasUseServerDirective(path)) {
          return;
        }
        assertIsAsyncFn(path);

        const fnId = path.node.id;
        if (!fnId) {
          throw path.buildCodeFrameError(
            'Internal error: expected FunctionDeclaration to have a name'
          );
        }

        const freeVariables = getFreeVariables(path);
        const { extractedIdentifier, getReplacement } = extractInlineActionToTopLevel(path, state, {
          freeVariables,
          body: path.node.body,
        });

        const tlb = getTopLevelBinding(path);
        if (tlb) {
          // we're at the top level, and we might be enclosed within a `export` decl.
          // we have to keep the export in place, because it might be used elsewhere,
          // so we can't just remove this node.
          // replace the function decl with a (hopefully) equivalent var declaration
          // `var [name] = $$INLINE_ACTION_{N}`

          const bindingKind = 'var';
          const [inserted] = path.replaceWith(
            t.variableDeclaration(bindingKind, [t.variableDeclarator(fnId, extractedIdentifier)])
          );
          tlb.scope.registerBinding(bindingKind, inserted);
        } else {
          // note: if we do this *after* adding the new declaration, the bindings get messed up
          path.remove();
          // add a declaration in the place where the function decl would be hoisted to.
          // (this avoids issues with functions defined after `return`, see `test-cases/named-after-return.jsx`)
          path.scope.push({
            id: fnId,
            init: getReplacement(),
            kind: 'var',
            unique: true,
          });
        }
        assertExpoMetadata(state.file.metadata);
        state.file.metadata.extractedActions.push({
          localName: tlb?.identifier.name,
          exportedName: extractedIdentifier.name,
        });
      },

      // `const foo = function() { ... }`
      FunctionExpression(path, state) {
        if (!hasUseServerDirective(path)) {
          return;
        }
        assertIsAsyncFn(path);

        const { body } = path.node;
        const freeVariables = getFreeVariables(path);

        // TODO: look for usages of the name (if present), that's technically possible
        // const fnId = path.node.id;

        const { extractedIdentifier, getReplacement } = extractInlineActionToTopLevel(path, state, {
          freeVariables,
          body,
        });

        const tlb = getTopLevelBinding(path);

        assertExpoMetadata(state.file.metadata);
        path.replaceWith(getReplacement());
        state.file.metadata.extractedActions.push({
          localName: tlb?.identifier.name,
          exportedName: extractedIdentifier.name,
        });
      },

      // Top-level "use server"

      ExportDefaultDeclaration(path, state) {
        assertExpoMetadata(state.file.metadata);
        if (!state.file.metadata.isModuleMarkedWithUseServerDirective) {
          return;
        }

        // Convert `export default function foo() {}` to `function foo() {}; export { foo as default }`
        if (path.node.declaration) {
          if (t.isFunctionDeclaration(path.node.declaration)) {
            let { id } = path.node.declaration;
            if (id == null) {
              const moduleScope = path.scope.getProgramParent();
              const extractedIdentifier = moduleScope.generateUidIdentifier('$$INLINE_ACTION');
              id = extractedIdentifier;
              // Transform `async function () {}` to `async function $$INLINE_ACTION() {}`
              path.node.declaration.id = extractedIdentifier;
            }
            const exportedSpecifier = t.exportSpecifier(id, t.identifier('default'));
            path.replaceWith(path.node.declaration);
            path.insertAfter(t.exportNamedDeclaration(null, [exportedSpecifier]));
          } else {
            // Convert anonymous function expressions to named function expressions and export them as default.
            // export default foo = async () => {}
            // vvv
            // const foo = async () => {}
            // (() => _registerServerReference(foo, "file:///unknown", "default"))();
            // export { foo as default };

            if (
              t.isAssignmentExpression(path.node.declaration) &&
              t.isArrowFunctionExpression(path.node.declaration.right)
            ) {
              if (!t.isIdentifier(path.node.declaration.left)) {
                throw path.buildCodeFrameError(
                  `Expected an assignment to an identifier but found ${path.node.declaration.left.type}.`
                );
              }
              const { left, right } = path.node.declaration;
              const id = left;
              const exportedSpecifier = t.exportSpecifier(id, t.identifier('default'));
              // Replace `export default foo = async () => {}` with `const foo = async () => {}`
              path.replaceWith(t.variableDeclaration('var', [t.variableDeclarator(id, right)]));
              // Insert `(() => _registerServerReference(foo, "file:///unknown", "default"))();`
              path.insertAfter(t.exportNamedDeclaration(null, [exportedSpecifier]));
            } else if (
              t.isArrowFunctionExpression(path.node.declaration) &&
              path.node.declaration
            ) {
              // export default async () => {}
              // Give the function a name
              // const $$INLINE_ACTION = async () => {}
              const moduleScope = path.scope.getProgramParent();
              const extractedIdentifier = moduleScope.generateUidIdentifier('$$INLINE_ACTION');

              // @ts-expect-error: Transform `export default async () => {}` to `const $$INLINE_ACTION = async () => {}`
              path.node.declaration = t.variableDeclaration('var', [
                t.variableDeclarator(extractedIdentifier, path.node.declaration),
              ]);
              // Strip the `export default`
              path.replaceWith(path.node.declaration);

              // export { $$INLINE_ACTION as default }
              const exportedSpecifier = t.exportSpecifier(
                extractedIdentifier,
                t.identifier('default')
              );
              path.insertAfter(t.exportNamedDeclaration(null, [exportedSpecifier]));
            } else if (
              // Match `export default foo;`
              t.isIdentifier(path.node.declaration)
            ) {
              // Ensure the `path.node.declaration` is a function or a variable for a function.
              const binding = path.scope.getBinding(path.node.declaration.name);

              const isServerActionType =
                t.isFunctionDeclaration(binding?.path.node ?? path.node.declaration) ||
                t.isArrowFunctionExpression(binding?.path.node ?? path.node.declaration) ||
                // `const foo = async () => {}`
                (t.isVariableDeclarator(binding?.path.node) &&
                  t.isArrowFunctionExpression(binding?.path.node.init));
              if (isServerActionType) {
                // Convert `export default foo;` to `export { foo as default };`
                const exportedSpecifier = t.exportSpecifier(
                  path.node.declaration,
                  t.identifier('default')
                );
                path.replaceWith(t.exportNamedDeclaration(null, [exportedSpecifier]));
              }
            } else {
              // Unclear when this happens.
              throw path.buildCodeFrameError(
                `Cannot create server action. Expected a assignment expression but found ${path.node.declaration.type}.`
              );
            }
          }
        } else {
          // TODO: Unclear when this happens.
          throw path.buildCodeFrameError(
            `Not implemented: 'export default' declarations in "use server" files. Try using 'export { name as default }' instead.`
          );
        }
      },

      ExportNamedDeclaration(path: NodePath<t.ExportNamedDeclaration>, state: PluginPass) {
        assertExpoMetadata(state.file.metadata);

        if (!state.file.metadata.isModuleMarkedWithUseServerDirective) {
          return;
        }
        // Skip type-only exports (`export type { Foo } from '...'` or `export { type Foo }`)
        if (path.node.exportKind === 'type') {
          return;
        }

        // This can happen with `export {};` and TypeScript types.
        if (!path.node.declaration && !path.node.specifiers.length) {
          return;
        }

        const actionModuleId = getActionModuleId();

        const createRegisterCall = (
          identifier: t.Identifier,
          exported: t.Identifier | t.StringLiteral = identifier
        ) => {
          const exportedName = t.isIdentifier(exported) ? exported.name : exported.value;
          const call = t.callExpression(addReactImport(), [
            identifier,
            t.stringLiteral(actionModuleId),
            t.stringLiteral(exportedName),
          ]);

          // Wrap call with `;(() => { ... })();` to avoid issues with ASI
          return t.expressionStatement(t.callExpression(t.arrowFunctionExpression([], call), []));
        };

        if (path.node.specifiers.length > 0) {
          for (const specifier of path.node.specifiers) {
            // `export * as ns from './foo';`
            if (t.isExportNamespaceSpecifier(specifier)) {
              throw path.buildCodeFrameError(
                'Namespace exports for server actions are not supported. Re-export named actions instead: export { foo } from "./bar".'
              );
            } else if (t.isExportDefaultSpecifier(specifier)) {
              // NOTE: This is handled by ExportDefaultDeclaration
              // `export default foo;`
              throw path.buildCodeFrameError(
                'Internal error while extracting server actions. Expected `export default variable;` to be extracted. (ExportDefaultSpecifier in ExportNamedDeclaration)'
              );
            } else if (t.isExportSpecifier(specifier)) {
              // Skip TypeScript type re-exports (e.g., `export { type Foo }`)
              if (specifier.exportKind === 'type') {
                continue;
              }

              // `export { foo };`
              // `export { foo as [bar|default] };`
              const localName = specifier.local.name;
              const exportedName = t.isIdentifier(specifier.exported)
                ? specifier.exported.name
                : specifier.exported.value;

              // if we're reexporting an existing action under a new name, we shouldn't register() it again.
              if (
                !state.file.metadata.extractedActions.some((info) => info.localName === localName)
              ) {
                // referencing the function's local identifier here *should* be safe (w.r.t. TDZ) because
                // 1. if it's a `export async function foo() {}`, the declaration will be hoisted,
                //    so it's safe to reference no matter how the declarations are ordered
                // 2. if it's an `export const foo = async () => {}`, then the standalone `export { foo }`
                //    has to follow the definition, so we can reference it right before the export decl as well
                path.insertBefore(createRegisterCall(specifier.local, specifier.exported));
              }

              state.file.metadata.extractedActions.push({ localName, exportedName });
            }
          }
          return;
        }

        if (!path.node.declaration) {
          throw path.buildCodeFrameError(
            `Internal error: Unexpected 'ExportNamedDeclaration' without declarations`
          );
        }

        const identifiers: t.Identifier[] = (() => {
          const innerPath = path.get('declaration');
          if (innerPath.isVariableDeclaration()) {
            return innerPath.get('declarations').map((d) => {
              // TODO: insert `typeof <identifier> === 'function'` check -- it's a variable, so it could be anything
              const id = d.node.id;
              if (!t.isIdentifier(id)) {
                // TODO
                throw innerPath.buildCodeFrameError('Unimplemented');
              }
              return id;
            });
          } else if (innerPath.isFunctionDeclaration()) {
            if (!innerPath.get('async')) {
              throw innerPath.buildCodeFrameError(
                `Functions exported from "use server" files must be async.`
              );
            }
            return [innerPath.get('id').node!];
          } else if (
            // TypeScript type exports
            innerPath.isTypeAlias() ||
            innerPath.isTSDeclareFunction() ||
            innerPath.isTSInterfaceDeclaration() ||
            innerPath.isTSTypeAliasDeclaration()
          ) {
            return [];
          } else {
            throw innerPath.buildCodeFrameError(`Unimplemented server action export`);
          }
        })();

        path.insertAfter(identifiers.map((identifier) => createRegisterCall(identifier)));

        for (const identifier of identifiers) {
          state.file.metadata.extractedActions.push({
            localName: identifier.name,
            exportedName: identifier.name,
          });
        }
      },
    },

    post(file) {
      assertExpoMetadata(file.metadata);
      if (!file.metadata.extractedActions?.length) {
        return;
      }
      debug('extracted actions', file.metadata.extractedActions);
      const payload = {
        id: getActionModuleId(),
        names: file.metadata.extractedActions.map((e) => e.exportedName),
      };
      const stashedData = 'rsc/actions: ' + JSON.stringify(payload);
      // Add comment for debugging the bundle, we use the babel metadata for accessing the data.
      file.path.addComment('leading', stashedData);

      const filePath = file.opts.filename;

      if (!filePath) {
        // This can happen in tests or systems that use Babel standalone.
        throw new Error('[Babel] Expected a filename to be set in the state');
      }

      const outputKey = url.pathToFileURL(filePath).href;

      file.metadata.reactServerActions = payload;
      file.metadata.reactServerReference = outputKey;
    },
  };
}

const getFreeVariables = (path: FnPath) => {
  const freeVariablesSet = new Set<string>();
  const programScope = path.scope.getProgramParent();
  path.traverse({
    Identifier(innerPath) {
      const { name } = innerPath.node;

      if (!innerPath.isReferencedIdentifier()) {
        debug('skipping - not referenced');
        return;
      }

      if (freeVariablesSet.has(name)) {
        // we've already determined this name to be a free var. no point in recomputing.
        debug('skipping - already registered');
        return;
      }

      const binding = innerPath.scope.getBinding(name);
      if (!binding) {
        // probably a global, or an unbound variable. ignore it.
        debug('skipping - global or unbound, skipping');
        return;
      }
      if (binding.scope === programScope) {
        // module-level declaration. no need to close over it.
        debug('skipping - module-level binding');
        return;
      }

      if (
        // function args or a var at the top-level of its body
        binding.scope === path.scope ||
        // decls from blocks within the function
        isChildScope({
          parent: path.scope,
          child: binding.scope,
          root: programScope,
        })
      ) {
        // the binding came from within the function = it's not closed-over, so don't add it.
        debug('skipping - declared within function');
        return;
      }

      // we've (hopefully) eliminated all the other cases, so we should treat this as a free var.
      debug('adding');
      freeVariablesSet.add(name);
    },
  });
  return [...freeVariablesSet].sort();
};

const getFnPathName = (path: FnPath) => {
  return path.isArrowFunctionExpression() ? undefined : path.node?.id?.name;
};

const isChildScope = ({
  root,
  parent,
  child,
}: {
  root: NodePath['scope'];
  parent: NodePath['scope'];
  child: NodePath['scope'];
}) => {
  let curScope = child;
  while (curScope !== root) {
    if (curScope.parent === parent) {
      return true;
    }
    curScope = curScope.parent;
  }
  return false;
};

function findImmediatelyEnclosingDeclaration(path: FnPath) {
  let currentPath: NodePath = path;
  while (!currentPath.isProgram()) {
    if (
      // const foo = async () => { ... }
      //       ^^^^^^^^^^^^^^^^^^^^^^^^^
      currentPath.isVariableDeclarator() ||
      // async function foo() { ... }
      // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
      currentPath.isDeclaration()
    ) {
      return currentPath;
    }
    // if we encounter an expression on the way, this isn't a top level decl, and needs to be hoisted.
    // e.g. `export const foo = withAuth(async () => { ... })`
    if (currentPath !== path && currentPath.isExpression()) {
      return null;
    }
    if (!currentPath.parentPath) {
      return null;
    }
    currentPath = currentPath.parentPath;
  }
  return null;
}
const getTopLevelBinding = (path: FnPath) => {
  const decl = findImmediatelyEnclosingDeclaration(path);
  if (!decl || !('id' in decl.node) || !decl.node.id || !('name' in decl.node.id)) return null;
  const declBinding = decl.scope.getBinding(decl.node.id.name);
  return declBinding!.scope === path.scope.getProgramParent() ? declBinding : null;
};

const assertIsAsyncFn = (path: FnPath) => {
  if (!path.node.async) {
    throw path.buildCodeFrameError(`functions marked with "use server" must be async`);
  }
};

const once = <T>(fn: () => T) => {
  let cache: { has: boolean; value?: T } = { has: false };
  return (): T => {
    if (cache.has) return cache.value!;
    cache = { has: true, value: fn() };
    return cache.value!;
  };
};

function assertExpoMetadata(metadata: any): asserts metadata is {
  reactServerActions?: { id: string; names: string[] };
  reactServerReference?: string;
  extractedActions: ExtractedActionInfo[];
  isModuleMarkedWithUseServerDirective?: boolean;
} {
  if (!metadata || typeof metadata !== 'object') {
    throw new Error('Expected Babel state.file.metadata to be an object');
  }
}
